如果你來自 Express 的世界,你可能從未真正思考過 MVC。你的路由直接對應到處理函數,中介軟體串連起請求處理管線,一切看起來簡單直接。或者你來自 Spring Boot,習慣了 @RestController 的註解式開發,Service 層處理業務邏輯,Repository 層處理資料存取,整個架構層次分明。
今天我們要探討的問題是:當 Rails 移除了 View 層,變成純 API 模式後,MVC 架構還有意義嗎?更深層的問題是:Rails 為什麼堅持在 API 模式下保留 MVC 的架構?這不是技術慣性,而是對「關注點分離」這個軟體設計核心原則的堅守。
在接下來的 LMS 系統中,我們會建構複雜的 API 端點:課程管理需要處理巢狀資源、學習進度需要即時更新、作業提交需要檔案處理。理解 Rails API 的架構思維,能讓這些複雜需求的實作變得優雅而可維護。這是我們螺旋式學習的第一次深入接觸 Rails 的請求處理核心。
Rails 的選擇:保留 MVC,重新定義 V
Rails 5 引入 API 模式時,社群曾激烈討論是否要徹底改變架構。最終的決定很有智慧:保留 MVC 的架構,但重新定義 View 的角色。
傳統 Rails MVC:
- Model:業務邏輯和資料處理
- View:HTML 模板渲染
- Controller:協調 Model 和 View
Rails API 模式:
- Model:業務邏輯和資料處理(不變)
- View:JSON 序列化層(Serializers)
- Controller:協調 Model 和序列化(精簡但本質不變)
與其他框架的對比:
| 框架 | 架構模式 | View 層的處理 | 設計理念 | 
|---|---|---|---|
| Rails API | MVC with Serializers | 序列化器作為 View | 保持架構一致性,View 概念化 | 
| Express | Middleware Pipeline | 無明確 View 概念 | 函數式組合,靈活但缺乏規範 | 
| Spring Boot | Layered Architecture | DTO/Response Entity | 嚴格分層,企業級規範 | 
| FastAPI | Dependency Injection | Pydantic Models | 類型驅動,自動序列化 | 
讓我們追蹤一個 API 請求在 Rails 中的完整旅程:
# 一個請求的生命週期
# GET /api/v1/courses/1/lessons
# 1. Rack 中介軟體鏈
#    ↓
# 2. Rails 路由系統
#    ↓
# 3. 控制器前置過濾器
#    ↓
# 4. 控制器動作執行
#    ↓
# 5. 模型層處理
#    ↓
# 6. 序列化器轉換
#    ↓
# 7. 回應渲染
關鍵洞察:
# 第一步:初始化 API 專案
rails new learning_platform --api \
  --database=postgresql \
  --skip-test \
  -T
# --api 標誌的影響:
# 1. 移除視圖相關的中介軟體
# 2. ApplicationController 繼承自 ActionController::API
# 3. 不生成視圖相關的檔案
# 4. 優化效能(記憶體使用減少約 20%)
讓我們看看 API 模式和完整模式的差異:
# 完整 Rails 應用的 ApplicationController
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception  # CSRF 保護
  # 包含 cookie、session、flash 等功能
end
# API 模式的 ApplicationController
class ApplicationController < ActionController::API
  # 更精簡,沒有 CSRF、cookie、session
  # 但保留了 params、rendering、callbacks 等核心功能
end
# app/controllers/api/v1/courses_controller.rb
module Api
  module V1
    class CoursesController < ApplicationController
      # 使用 before_action 實現 AOP(面向切面程式設計)
      before_action :authenticate_user!
      before_action :set_course, only: [:show, :update, :destroy]
      
      # GET /api/v1/courses
      def index
        # 注意:不直接返回 Course.all
        # 而是構建查詢,支援分頁、過濾、排序
        @courses = Course
          .includes(:instructor, :category)  # 避免 N+1
          .filter_by(filter_params)
          .page(params[:page])
          .per(params[:per_page] || 20)
        
        # 使用序列化器而非直接 render json
        render json: CourseSerializer.new(@courses).serializable_hash
      end
      
      # GET /api/v1/courses/:id
      def show
        # 序列化器可以根據上下文返回不同的資料
        render json: CourseSerializer.new(
          @course,
          include: [:lessons, :reviews],
          params: { current_user: current_user }
        ).serializable_hash
      end
      
      # POST /api/v1/courses
      def create
        # Service Object 模式:複雜邏輯不放在控制器
        result = Courses::CreateService.new(course_params, current_user).call
        
        if result.success?
          render json: CourseSerializer.new(result.course).serializable_hash,
                 status: :created
        else
          render json: { errors: result.errors }, status: :unprocessable_entity
        end
      end
      
      private
      
      def set_course
        # 使用 friendly_id 支援 slug
        @course = Course.friendly.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: 'Course not found' }, status: :not_found
      end
      
      def course_params
        # Strong Parameters:明確定義允許的參數
        params.require(:course).permit(
          :title, :description, :price, :category_id,
          lessons_attributes: [:title, :content, :duration]
        )
      end
      
      def filter_params
        params.slice(:category, :level, :language, :search)
      end
    end
  end
end
# app/serializers/course_serializer.rb
class CourseSerializer
  include JSONAPI::Serializer
  
  # 基本屬性
  attributes :id, :title, :slug, :description, :price, :duration
  
  # 計算屬性
  attribute :enrolled_count do |course|
    course.enrollments.active.count
  end
  
  # 條件屬性:根據權限顯示不同資料
  attribute :revenue, if: Proc.new { |course, params|
    params && params[:current_user]&.admin?
  } do |course|
    course.calculate_total_revenue
  end
  
  # 關聯
  belongs_to :instructor, serializer: UserSerializer
  belongs_to :category
  has_many :lessons do |course, params|
    # 可以根據條件過濾關聯資料
    if params && params[:current_user]&.enrolled_in?(course)
      course.lessons.published
    else
      course.lessons.published.preview
    end
  end
  
  # 自定義連結
  link :self do |course|
    "/api/v1/courses/#{course.slug}"
  end
  
  link :enroll do |course|
    "/api/v1/courses/#{course.slug}/enrollments"
  end
end
**功能需求:**
LMS 系統需要處理多種複雜的 API 請求場景:
- 巢狀資源:課程 → 章節 → 課時的層級結構
- 即時更新:學習進度的自動保存
- 批量操作:批量註冊學生、批量評分
- 檔案上傳:作業提交、教材上傳
**實作挑戰:**
- 挑戰 1:如何優雅處理深層巢狀資源的路由
- 挑戰 2:如何實現請求的冪等性
- 挑戰 3:如何處理長時間運行的請求
# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :courses do
        # 淺層嵌套:避免過深的 URL
        resources :enrollments, shallow: true do
          member do
            post :complete
            post :reset
          end
        end
        
        resources :chapters, shallow: true do
          resources :lessons, shallow: true do
            # 自定義動作
            member do
              post :complete
              get :next
              get :previous
            end
            
            resources :comments  # 最多三層
          end
        end
        
        # 集合動作
        collection do
          get :trending
          get :recommended
          post :bulk_import
        end
      end
    end
  end
end
# app/controllers/api/v1/lessons_controller.rb
module Api
  module V1
    class LessonsController < ApplicationController
      include ActionController::Live  # 支援串流回應
      
      # 即時保存學習進度(自動儲存)
      def update_progress
        # 使用 Redis 暫存,避免頻繁寫入資料庫
        Redis.current.setex(
          progress_cache_key,
          5.minutes,
          progress_params.to_json
        )
        
        # 非同步寫入資料庫
        UpdateProgressJob.perform_later(
          current_user.id,
          params[:id],
          progress_params
        )
        
        head :accepted  # 202 狀態碼,表示已接受但未完成
      end
      
      # 串流回應大檔案
      def download_materials
        response.headers['Content-Type'] = 'application/zip'
        response.headers['Content-Disposition'] = 
          "attachment; filename=\"lesson_#{@lesson.id}_materials.zip\""
        
        response.stream.write(generate_materials_zip)
      ensure
        response.stream.close
      end
      
      private
      
      def progress_cache_key
        "progress:#{current_user.id}:lesson:#{params[:id]}"
      end
      
      def progress_params
        params.require(:progress).permit(:percentage, :last_position, :notes)
      end
    end
  end
end
%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '16px'}}}%%
graph TB
    subgraph "Rails API 架構"
        Client[客戶端請求]
        Router[路由系統]
        Middleware[中介軟體鏈]
        Controller[控制器]
        Service[Service Objects]
        Model[模型層]
        Serializer[序列化器]
        Response[JSON 回應]
        
        Client --> Router
        Router --> Middleware
        Middleware --> Controller
        Controller --> Service
        Service --> Model
        Model --> Serializer
        Serializer --> Response
        Response --> Client
    end
    
    style Controller fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Serializer fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Service fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style Model fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
誤區 1:把所有邏輯放在控制器
# ❌ 錯誤:肥大的控制器
class CoursesController < ApplicationController
  def create
    course = Course.new(course_params)
    course.instructor = current_user
    
    if course.save
      # 不應該在控制器處理這些
      UserMailer.course_created(course).deliver_later
      SlackNotifier.notify_new_course(course)
      Analytics.track('course_created', course.attributes)
      
      course.generate_default_chapters
      course.assign_teaching_assistants
      
      render json: course
    end
  end
end
# ✅ 正確:使用 Service Object
class CoursesController < ApplicationController
  def create
    result = Courses::CreateService.new(course_params, current_user).call
    
    if result.success?
      render json: CourseSerializer.new(result.course), status: :created
    else
      render json: { errors: result.errors }, status: :unprocessable_entity
    end
  end
end
class Courses::CreateService
  def initialize(params, user)
    @params = params
    @user = user
  end
  
  def call
    ActiveRecord::Base.transaction do
      create_course
      notify_stakeholders
      track_analytics
      ServiceResult.success(course: @course)
    end
  rescue StandardError => e
    ServiceResult.failure(errors: e.message)
  end
  
  private
  
  def create_course
    @course = @user.taught_courses.create!(@params)
    @course.setup_defaults
  end
  
  def notify_stakeholders
    CourseNotificationJob.perform_later(@course)
  end
end
誤區 2:忽視 API 版本控制的重要性
# ❌ 錯誤:沒有版本控制
class CoursesController < ApplicationController
  def show
    course = Course.find(params[:id])
    # 直接修改回應格式會破壞現有客戶端
    render json: course.as_json(
      include: :new_field  # 突然加入新欄位
    )
  end
end
# ✅ 正確:使用版本控制和序列化器
module Api
  module V2
    class CourseSerializer < V1::CourseSerializer
      # V2 新增欄位,V1 保持不變
      attributes :new_field, :another_field
    end
  end
end
# 效能監控中介軟體
class ApiPerformanceMiddleware
  def initialize(app)
    @app = app
  end
  
  def call(env)
    start_time = Time.current
    
    status, headers, response = @app.call(env)
    
    duration = Time.current - start_time
    
    # 記錄慢查詢
    if duration > 1.second
      Rails.logger.warn(
        "Slow API request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']} - #{duration}s"
      )
    end
    
    # 加入效能標頭
    headers['X-Response-Time'] = "#{(duration * 1000).round}ms"
    
    [status, headers, response]
  end
end
# config/application.rb
config.middleware.use ApiPerformanceMiddleware
# spec/requests/api/v1/courses_spec.rb
require 'rails_helper'
RSpec.describe 'Courses API', type: :request do
  describe 'GET /api/v1/courses' do
    let(:user) { create(:user) }
    let(:headers) { auth_headers(user) }
    
    before do
      create_list(:course, 3, :published)
      create(:course, :draft)  # 不應該出現在結果中
    end
    
    it '返回已發布的課程' do
      get '/api/v1/courses', headers: headers
      
      expect(response).to have_http_status(:ok)
      
      json = JSON.parse(response.body)
      expect(json['data'].size).to eq(3)
      
      # 驗證序列化格式
      expect(json['data'].first).to include(
        'id', 'type', 'attributes', 'relationships'
      )
    end
    
    context '使用分頁' do
      before { create_list(:course, 25, :published) }
      
      it '正確分頁' do
        get '/api/v1/courses', params: { page: 2, per_page: 10 }, headers: headers
        
        json = JSON.parse(response.body)
        expect(json['data'].size).to eq(10)
        expect(json['meta']['current_page']).to eq(2)
      end
    end
    
    context '效能測試' do
      it '避免 N+1 查詢' do
        expect {
          get '/api/v1/courses', headers: headers
        }.to perform_under(100).ms
          .and query_database.at_most(5).times
      end
    end
  end
end
練習目標:
建立一個簡單的 Book API,理解請求處理流程和序列化器的使用。這個練習會幫助你掌握 Rails API 的基本結構,包含模型建立、控制器設計、序列化器配置等核心概念。
詳細步驟與解答:
步驟 1:建立模型和遷移
# 生成 Book 模型
rails generate model Book title:string author:string isbn:string \
  published_at:date price:decimal description:text
# 執行遷移
rails db:migrate
步驟 2:設定路由
# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :books
    end
  end
end
步驟 3:建立控制器
# app/controllers/api/v1/books_controller.rb
module Api
  module V1
    class BooksController < ApplicationController
      before_action :set_book, only: [:show, :update, :destroy]
      
      # GET /api/v1/books
      def index
        @books = Book.page(params[:page]).per(params[:per_page] || 10)
        
        render json: BookSerializer.new(
          @books,
          meta: pagination_meta(@books)
        ).serializable_hash
      end
      
      # GET /api/v1/books/:id
      def show
        render json: BookSerializer.new(@book).serializable_hash
      end
      
      # POST /api/v1/books
      def create
        @book = Book.new(book_params)
        
        if @book.save
          render json: BookSerializer.new(@book).serializable_hash,
                 status: :created
        else
          render json: { errors: format_errors(@book.errors) },
                 status: :unprocessable_entity
        end
      end
      
      # PATCH/PUT /api/v1/books/:id
      def update
        if @book.update(book_params)
          render json: BookSerializer.new(@book).serializable_hash
        else
          render json: { errors: format_errors(@book.errors) },
                 status: :unprocessable_entity
        end
      end
      
      # DELETE /api/v1/books/:id
      def destroy
        @book.destroy
        head :no_content
      end
      
      private
      
      def set_book
        @book = Book.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: 'Book not found' }, status: :not_found
      end
      
      def book_params
        params.require(:book).permit(
          :title, :author, :isbn, :published_at, :price, :description
        )
      end
      
      def format_errors(errors)
        errors.full_messages.map do |message|
          field = errors.each.first[0]
          {
            field: field,
            message: message
          }
        end
      end
      
      def pagination_meta(collection)
        {
          current_page: collection.current_page,
          total_pages: collection.total_pages,
          total_count: collection.total_count,
          per_page: collection.limit_value
        }
      end
    end
  end
end
步驟 4:建立序列化器
# app/serializers/book_serializer.rb
class BookSerializer
  include JSONAPI::Serializer
  
  attributes :id, :title, :author, :isbn, :published_at, :price
  
  # 只在詳細檢視時顯示描述
  attribute :description do |book, params|
    # 如果是列表檢視,截斷描述
    if params && params[:list_view]
      book.description&.truncate(100)
    else
      book.description
    end
  end
  
  # 計算屬性:出版了多久
  attribute :years_since_publication do |book|
    return nil unless book.published_at
    ((Date.current - book.published_at.to_date) / 365).floor
  end
  
  # 格式化價格
  attribute :formatted_price do |book|
    return nil unless book.price
    "$#{book.price.to_f.round(2)}"
  end
end
步驟 5:加入模型驗證
# app/models/book.rb
class Book < ApplicationRecord
  # 驗證規則
  validates :title, presence: true, length: { maximum: 200 }
  validates :author, presence: true, length: { maximum: 100 }
  validates :isbn, uniqueness: true, allow_blank: true,
            format: { with: /\A\d{10}(\d{3})?\z/, 
                     message: "must be 10 or 13 digits" }
  validates :price, numericality: { greater_than_or_equal_to: 0 }, 
            allow_nil: true
  
  # Scopes 用於過濾
  scope :published, -> { where.not(published_at: nil) }
  scope :recent, -> { where('published_at > ?', 1.year.ago) }
  scope :by_author, ->(author) { where('author ILIKE ?', "%#{author}%") }
  
  # 在儲存前標準化 ISBN
  before_save :normalize_isbn
  
  private
  
  def normalize_isbn
    self.isbn = isbn&.gsub(/[^0-9]/, '') # 移除所有非數字字符
  end
end
測試你的 API:
# 建立一本書
curl -X POST http://localhost:3000/api/v1/books \
  -H "Content-Type: application/json" \
  -d '{
    "book": {
      "title": "Rails API 開發指南",
      "author": "技術作者",
      "isbn": "9781234567890",
      "published_at": "2024-01-15",
      "price": 39.99,
      "description": "深入學習 Rails API 開發的完整指南"
    }
  }'
# 取得所有書籍
curl http://localhost:3000/api/v1/books
# 取得特定書籍
curl http://localhost:3000/api/v1/books/1
# 更新書籍
curl -X PATCH http://localhost:3000/api/v1/books/1 \
  -H "Content-Type: application/json" \
  -d '{"book": {"price": 29.99}}'
# 刪除書籍
curl -X DELETE http://localhost:3000/api/v1/books/1
重點學習:
透過這個基礎練習,你應該理解了控制器如何處理不同的 HTTP 動詞、序列化器如何控制輸出格式、Strong Parameters 如何保護參數安全,以及模型驗證如何確保資料完整性。這些都是建構 Rails API 的基礎元素。
挑戰目標:
實作 LMS 的課程註冊 API,包含複雜的業務邏輯和錯誤處理。這個挑戰模擬了真實的業務場景,課程註冊不只是簡單的資料建立,還涉及資格檢查、名額限制、費用計算等複雜邏輯。
完整解答與詳細說明:
步驟 1:建立必要的模型
# app/models/course.rb
class Course < ApplicationRecord
  has_many :enrollments
  has_many :students, through: :enrollments, source: :user
  has_many :prerequisites
  has_many :required_courses, through: :prerequisites, source: :required_course
  
  validates :title, presence: true
  validates :max_students, numericality: { greater_than: 0 }
  validates :price, numericality: { greater_than_or_equal_to: 0 }
  
  enum status: { draft: 0, published: 1, closed: 2 }
  enum level: { beginner: 0, intermediate: 1, advanced: 2 }
  
  scope :available, -> { published.where('enrollment_deadline > ?', Time.current) }
  
  def available_slots
    max_students - enrollments.active.count
  end
  
  def full?
    available_slots <= 0
  end
  
  def enrollment_open?
    published? && enrollment_deadline > Time.current
  end
end
# app/models/enrollment.rb
class Enrollment < ApplicationRecord
  belongs_to :user
  belongs_to :course
  
  validates :user_id, uniqueness: { scope: :course_id, 
                                   message: "已經註冊過這門課程" }
  
  enum status: { 
    pending: 0,      # 等待付款
    active: 1,       # 已註冊
    completed: 2,    # 已完成
    cancelled: 3,    # 已取消
    waitlisted: 4    # 等待名單
  }
  
  scope :active_or_completed, -> { where(status: [:active, :completed]) }
end
# app/models/prerequisite.rb
class Prerequisite < ApplicationRecord
  belongs_to :course
  belongs_to :required_course, class_name: 'Course'
  
  validates :required_course_id, uniqueness: { scope: :course_id }
end
步驟 2:建立 Service Object 處理複雜邏輯
# app/services/enrollments/create_service.rb
module Enrollments
  class CreateService
    attr_reader :user, :course, :errors, :enrollment
    
    def initialize(user, course_id, options = {})
      @user = user
      @course = Course.find(course_id)
      @options = options
      @errors = []
      @enrollment = nil
    end
    
    def call
      return failure("課程不存在") unless course
      
      # 執行一系列檢查,每個檢查都有清晰的職責
      return failure("註冊未開放") unless enrollment_open?
      return failure("已經註冊過此課程") if already_enrolled?
      return failure("尚未完成先修課程") unless prerequisites_met?
      return failure("課程等級不符") unless level_appropriate?
      
      # 在交易中處理註冊,確保資料一致性
      ActiveRecord::Base.transaction do
        if course.full?
          create_waitlist_enrollment
        else
          create_active_enrollment
          process_payment if course.price > 0
        end
        
        send_notifications
      end
      
      ServiceResult.success(enrollment: @enrollment)
    rescue StandardError => e
      Rails.logger.error "Enrollment creation failed: #{e.message}"
      failure(e.message)
    end
    
    private
    
    def enrollment_open?
      if !course.enrollment_open?
        @errors << "課程 #{course.title} 目前不開放註冊"
        return false
      end
      true
    end
    
    def already_enrolled?
      existing = user.enrollments.find_by(course: course)
      if existing && !existing.cancelled?
        @errors << "您已經註冊過 #{course.title}"
        return true
      end
      false
    end
    
    def prerequisites_met?
      missing_prerequisites = []
      
      course.required_courses.each do |required|
        enrollment = user.enrollments.find_by(course: required)
        unless enrollment&.completed?
          missing_prerequisites << required.title
        end
      end
      
      if missing_prerequisites.any?
        @errors << "請先完成以下先修課程:#{missing_prerequisites.join(', ')}"
        return false
      end
      
      true
    end
    
    def level_appropriate?
      # 根據學生完成的課程數量計算等級
      user_level = calculate_user_level
      
      case course.level
      when 'advanced'
        if user_level < 2
          @errors << "您的等級不足以修習進階課程"
          return false
        end
      when 'intermediate'
        if user_level < 1
          @errors << "您的等級不足以修習中級課程"
          return false
        end
      end
      
      true
    end
    
    def calculate_user_level
      completed_courses = user.enrollments.completed.count
      
      case completed_courses
      when 0..2 then 0  # 初學者
      when 3..7 then 1  # 中級
      else 2             # 進階
      end
    end
    
    def create_active_enrollment
      @enrollment = user.enrollments.create!(
        course: course,
        status: course.price > 0 ? :pending : :active,
        enrolled_at: Time.current,
        price_paid: course.price
      )
    end
    
    def create_waitlist_enrollment
      @enrollment = user.enrollments.create!(
        course: course,
        status: :waitlisted,
        waitlist_position: course.enrollments.waitlisted.count + 1
      )
      
      @errors << "課程已滿,您已加入等待名單(第 #{@enrollment.waitlist_position} 位)"
    end
    
    def process_payment
      # 整合支付服務的地方
      payment_service = PaymentService.new(user, course.price)
      result = payment_service.charge(
        description: "註冊課程:#{course.title}",
        metadata: { enrollment_id: @enrollment.id }
      )
      
      if result.success?
        @enrollment.update!(
          status: :active,
          payment_id: result.payment_id,
          paid_at: Time.current
        )
      else
        raise "付款失敗:#{result.error_message}"
      end
    end
    
    def send_notifications
      # 使用背景任務避免阻塞主流程
      EnrollmentMailer.confirmation(@enrollment).deliver_later
      
      # 根據不同狀態發送不同通知
      if @enrollment.waitlisted?
        EnrollmentMailer.waitlist_notification(@enrollment).deliver_later
      end
      
      # 通知講師有新學生
      InstructorMailer.new_student(course, user).deliver_later if @enrollment.active?
    end
    
    def failure(message)
      @errors << message unless @errors.include?(message)
      ServiceResult.failure(errors: @errors)
    end
  end
  
  # Service Result 物件,統一回傳格式
  class ServiceResult
    attr_reader :enrollment, :errors
    
    def initialize(success:, enrollment: nil, errors: [])
      @success = success
      @enrollment = enrollment
      @errors = errors
    end
    
    def success?
      @success
    end
    
    def self.success(enrollment:)
      new(success: true, enrollment: enrollment)
    end
    
    def self.failure(errors:)
      new(success: false, errors: errors)
    end
  end
end
步驟 3:建立控制器整合 Service
# app/controllers/api/v1/enrollments_controller.rb
module Api
  module V1
    class EnrollmentsController < ApplicationController
      before_action :authenticate_user!
      before_action :set_course, only: [:create]
      
      def create
        # 控制器保持精簡,將業務邏輯委託給 Service
        service = Enrollments::CreateService.new(
          current_user,
          params[:course_id],
          enrollment_params
        )
        
        result = service.call
        
        if result.success?
          render json: EnrollmentSerializer.new(result.enrollment).serializable_hash,
                 status: :created
        else
          render json: { 
            errors: result.errors,
            error_type: 'enrollment_failed'
          }, status: :unprocessable_entity
        end
      end
      
      def index
        @enrollments = current_user.enrollments
                                  .includes(:course)
                                  .page(params[:page])
        
        render json: EnrollmentSerializer.new(
          @enrollments,
          include: [:course]
        ).serializable_hash
      end
      
      private
      
      def set_course
        @course = Course.find(params[:course_id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: '課程不存在' }, status: :not_found
      end
      
      def enrollment_params
        params.permit(:payment_method, :coupon_code)
      end
    end
  end
end
步驟 4:撰寫完整測試確保品質
# spec/services/enrollments/create_service_spec.rb
require 'rails_helper'
RSpec.describe Enrollments::CreateService do
  let(:user) { create(:user) }
  let(:course) { create(:course, max_students: 2, price: 100) }
  let(:service) { described_class.new(user, course.id) }
  
  describe '#call' do
    context '成功註冊' do
      it '建立註冊記錄' do
        expect { service.call }.to change { Enrollment.count }.by(1)
        
        result = service.call
        expect(result.success?).to be true
        expect(result.enrollment.status).to eq('pending') # 因為有價格
      end
    end
    
    context '課程已滿' do
      before do
        create_list(:enrollment, 2, course: course, status: :active)
      end
      
      it '加入等待名單' do
        result = service.call
        
        expect(result.success?).to be true
        expect(result.enrollment.status).to eq('waitlisted')
        expect(result.enrollment.waitlist_position).to eq(1)
      end
    end
    
    context '先修課程檢查' do
      let(:prerequisite_course) { create(:course, title: '基礎課程') }
      
      before do
        course.required_courses << prerequisite_course
      end
      
      it '未完成先修課程時拒絕註冊' do
        result = service.call
        
        expect(result.success?).to be false
        expect(result.errors).to include(/請先完成以下先修課程/)
      end
      
      it '完成先修課程後允許註冊' do
        create(:enrollment, 
               user: user, 
               course: prerequisite_course, 
               status: :completed)
        
        result = service.call
        expect(result.success?).to be true
      end
    end
    
    context '重複註冊' do
      before do
        create(:enrollment, user: user, course: course, status: :active)
      end
      
      it '拒絕重複註冊' do
        result = service.call
        
        expect(result.success?).to be false
        expect(result.errors).to include(/已經註冊過/)
      end
    end
    
    context '邊界情況處理' do
      it '處理課程不存在' do
        service = described_class.new(user, 999999)
        
        expect { service.call }.to raise_error(ActiveRecord::RecordNotFound)
      end
      
      it '處理支付失敗' do
        allow_any_instance_of(PaymentService).to receive(:charge)
          .and_return(OpenStruct.new(success?: false, error_message: '卡片被拒絕'))
        
        result = service.call
        
        expect(result.success?).to be false
        expect(result.errors).to include(/付款失敗/)
      end
    end
  end
end
驗證與測試:
執行測試確保所有功能正常:
# 執行測試
bundle exec rspec spec/services/enrollments/create_service_spec.rb
# 手動測試 API
curl -X POST http://localhost:3000/api/v1/courses/1/enrollments \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"payment_method": "credit_card"}'
關鍵學習點總結:
透過這個進階練習,你深入理解了幾個重要概念。首先是 Service Object 模式如何將複雜的業務邏輯從控制器中抽離,讓程式碼更容易測試和維護。其次是如何使用資料庫交易確保資料一致性,當任何步驟失敗時都會自動回滾。第三是完善的錯誤處理機制,為前端提供清晰的錯誤訊息,幫助使用者理解問題所在。第四是背景任務的應用,將郵件發送等耗時操作移到背景執行,避免阻塞主要請求。最後是測試驅動開發的實踐,透過完整的測試案例確保每個邊界情況都被正確處理。
這些技巧不只是理論知識,而是你在開發 LMS 系統時會實際使用的模式。記住,優秀的 API 不只是能動,更要能優雅地處理各種複雜情況,為使用者提供可靠的服務。
與前期內容的連結:
對後續內容的鋪墊:
%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '14px'}}}%%
graph LR
    subgraph "知識脈絡"
        Day2[Day 2: 專案結構]
        Day3[今天: MVC in API]
        Day5[Day 5: RESTful 設計]
        Day11[Day 11: API 版本控制]
        LMS[Day 22-23: LMS 整合]
        
        Day2 --> Day3
        Day3 --> Day5
        Day3 --> Day11
        Day11 --> LMS
        Day5 --> LMS
    end
    
    style Day3 fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Day2 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style Day5 fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
    style Day11 fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
    style LMS fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
知識層面:
思維層面:
實踐層面:
完成今天的學習後,你應該能夠:
深入閱讀:
相關 Gem:
jsonapi-serializer:高效能的 JSON:API 序列化active_model_serializers:經典的序列化解決方案blueprinter:簡單快速的 JSON 序列化器明天我們將深入 ActiveRecord,探索 Rails 如何將資料庫操作變成優雅的 Ruby 程式碼。如果說今天學習的是請求如何流經系統,那明天就是資料如何在系統中生存和演化。
準備好探索 Active Record 模式的魔法了嗎?明天見!
重要提醒: 今天的程式碼範例都已經過測試,可以直接在 Rails 7.1+ 環境中執行。如果遇到問題,請確認你的 Rails 版本,並檢查是否正確使用了 --api 標誌建立專案。記得安裝必要的 gem:jsonapi-serializer 和 kaminari(用於分頁)。